今天我們要來實作後端的多租戶架構。在 SaaS 應用中,多租戶架構的設計直接影響到系統的擴展性、安全性和效能。我們將實作 Database-per-Tenant 策略,每個租戶擁有獨立的資料庫,確保資料隔離性和客製化需求。
在實作之前,我們先了解三種主要的多租戶資料庫策略:
我們的 Database-per-Tenant 架構包含以下核心組件:
// packages/kyo-core/src/database/tenant-connection.ts
interface TenantDatabaseConnection {
  tenantId: string;
  connectionString: string;
  pool: Pool;
  isActive: boolean;
  lastUsed: Date;
  maxConnections: number;
}
連線管理器負責管理多個租戶資料庫的連線池,實現動態建立、重用和清理連線:
export class TenantConnectionManager {
  private connections = new Map<string, TenantDatabaseConnection>();
  private readonly maxIdleTime = 30 * 60 * 1000; // 30分鐘
  private cleanupInterval: NodeJS.Timeout;
  constructor() {
    // 每5分鐘清理一次閒置連線
    this.cleanupInterval = setInterval(() => {
      this.cleanupIdleConnections();
    }, 5 * 60 * 1000);
  }
  async getConnection(tenantId: string): Promise<Pool> {
    let connection = this.connections.get(tenantId);
    if (!connection || !connection.isActive) {
      connection = await this.createConnection(tenantId);
      this.connections.set(tenantId, connection);
    }
    connection.lastUsed = new Date();
    return connection.pool;
  }
  private async createConnection(tenantId: string): Promise<TenantDatabaseConnection> {
    const connectionString = this.buildConnectionString(tenantId);
    const pool = new Pool({
      connectionString,
      max: 10, // 每個租戶最多10個連線
      idleTimeoutMillis: 30000,
      connectionTimeoutMillis: 2000,
    });
    // 測試連線
    try {
      const client = await pool.connect();
      client.release();
    } catch (error) {
      await pool.end();
      throw new Error(`Failed to connect to tenant database: ${tenantId}`);
    }
    return {
      tenantId,
      connectionString,
      pool,
      isActive: true,
      lastUsed: new Date(),
      maxConnections: 10,
    };
  }
  private buildConnectionString(tenantId: string): string {
    const baseUrl = process.env.DATABASE_URL || 'postgresql://localhost:5432';
    return `${baseUrl}/${this.getDatabaseName(tenantId)}`;
  }
  private getDatabaseName(tenantId: string): string {
    // 租戶資料庫命名規則:kyo_tenant_{tenantId}
    return `kyo_tenant_${tenantId.replace(/-/g, '_')}`;
  }
}
租戶資料庫服務負責資料庫的生命週期管理,包括建立、初始化、備份和刪除:
// packages/kyo-core/src/database/tenant-service.ts
export class TenantDatabaseService {
  constructor(private connectionManager: TenantConnectionManager) {}
  async createTenantDatabase(request: CreateTenantDatabaseRequest): Promise<TenantDatabaseInfo> {
    const { tenantId, plan, settings } = request;
    const databaseName = this.getDatabaseName(tenantId);
    try {
      // 1. 建立資料庫
      await this.createDatabase(databaseName);
      // 2. 初始化 Schema
      await this.initializeSchema(tenantId, plan);
      // 3. 建立預設資料
      await this.seedDefaultData(tenantId, settings);
      // 4. 設定備份排程
      await this.setupBackupSchedule(tenantId, plan);
      return {
        tenantId,
        databaseName,
        status: 'active',
        createdAt: new Date(),
        plan,
        connectionString: this.buildConnectionString(tenantId),
        backupEnabled: plan !== 'free',
      };
    } catch (error) {
      // 失敗時清理已建立的資源
      await this.rollbackDatabase(databaseName);
      throw error;
    }
  }
  private async initializeSchema(tenantId: string, plan: string): Promise<void> {
    const pool = await this.connectionManager.getConnection(tenantId);
    // 根據方案決定要建立的表格
    const baseTables = [
      'users', 'members', 'check_ins', 'member_ships',
      'payments', 'services', 'staff', 'roles'
    ];
    const premiumTables = plan === 'premium' || plan === 'enterprise'
      ? ['analytics', 'reports', 'integrations', 'custom_fields']
      : [];
    const tables = [...baseTables, ...premiumTables];
    for (const table of tables) {
      const schema = await this.getTableSchema(table, plan);
      await pool.query(schema);
    }
    // 建立索引和約束
    await this.createIndexes(pool, plan);
    await this.createConstraints(pool, plan);
  }
  private async seedDefaultData(tenantId: string, settings: TenantSettings): Promise<void> {
    const pool = await this.connectionManager.getConnection(tenantId);
    // 建立預設管理員使用者
    await pool.query(`
      INSERT INTO users (id, email, name, role, is_active, created_at)
      VALUES ($1, $2, $3, $4, $5, $6)
    `, [
      `admin-${tenantId}`,
      settings.adminEmail,
      settings.adminName,
      'admin',
      true,
      new Date()
    ]);
    // 建立預設服務項目
    const defaultServices = [
      { name: '健身房使用', price: 1500, duration: 30 },
      { name: '個人教練', price: 3000, duration: 60 },
      { name: '團體課程', price: 800, duration: 45 },
    ];
    for (const service of defaultServices) {
      await pool.query(`
        INSERT INTO services (id, name, price, duration_minutes, is_active, created_at)
        VALUES ($1, $2, $3, $4, $5, $6)
      `, [
        `service-${Date.now()}-${Math.random()}`,
        service.name,
        service.price,
        service.duration,
        true,
        new Date()
      ]);
    }
  }
}
我們實作了完整的 RBAC (Role-Based Access Control) 系統,支援七種使用者角色:
// packages/kyo-core/src/auth/auth-service.ts
export class AuthService {
  private readonly jwtSecret: string;
  private readonly tokenExpiry = '24h';
  private readonly refreshExpiry = '7d';
  constructor(jwtSecret?: string) {
    this.jwtSecret = jwtSecret || process.env.JWT_SECRET || 'dev-secret-key';
  }
  async authenticateUser(tenantId: string, credentials: LoginCredentials): Promise<AuthResult> {
    const pool = await tenantConnectionManager.getConnection(tenantId);
    // 驗證使用者憑證
    const user = await this.validateCredentials(pool, credentials);
    if (!user) {
      throw new KyoError('E_AUTH_INVALID_CREDENTIALS', 'Invalid credentials', 401);
    }
    // 檢查使用者狀態
    if (!user.isActive) {
      throw new KyoError('E_AUTH_USER_INACTIVE', 'User account is inactive', 403);
    }
    // 產生 Token
    const tokens = await this.generateTokens(user, tenantId);
    // 記錄登入
    await this.logUserActivity(pool, user.id, 'login', {
      ip: credentials.ip,
      userAgent: credentials.userAgent,
    });
    return {
      user: this.sanitizeUser(user),
      accessToken: tokens.accessToken,
      refreshToken: tokens.refreshToken,
      expiresIn: 24 * 60 * 60, // 24 hours in seconds
    };
  }
  async generateTokens(user: User, tenantId: string): Promise<TokenPair> {
    const payload: JWTPayload = {
      userId: user.id,
      tenantId,
      role: user.role,
      permissions: this.getRolePermissions(user.role),
      iat: Math.floor(Date.now() / 1000),
    };
    const accessToken = jwt.sign(payload, this.jwtSecret, {
      expiresIn: this.tokenExpiry,
      issuer: 'kyo-system',
      audience: tenantId,
    });
    const refreshToken = jwt.sign(
      { userId: user.id, tenantId, type: 'refresh' },
      this.jwtSecret,
      { expiresIn: this.refreshExpiry }
    );
    return { accessToken, refreshToken };
  }
  private getRolePermissions(role: UserRole): Permission[] {
    const rolePermissions: Record<UserRole, Permission[]> = {
      admin: ['*'], // 全部權限
      manager: [
        'members:read', 'members:write', 'members:delete',
        'staff:read', 'staff:write',
        'services:read', 'services:write',
        'reports:read', 'reports:export',
        'payments:read', 'payments:write',
      ],
      staff: [
        'members:read', 'members:write',
        'checkins:read', 'checkins:write',
        'services:read',
        'payments:read',
      ],
      trainer: [
        'members:read',
        'checkins:read', 'checkins:write',
        'services:read',
        'training:read', 'training:write',
      ],
      receptionist: [
        'members:read', 'members:write',
        'checkins:read', 'checkins:write',
        'services:read',
        'payments:read', 'payments:write',
      ],
      accountant: [
        'payments:read', 'payments:write', 'payments:export',
        'reports:read', 'reports:export',
        'members:read',
      ],
      member: [
        'profile:read', 'profile:write',
        'checkins:read',
        'services:read',
        'payments:read',
      ],
    };
    return rolePermissions[role] || [];
  }
  async verifyToken(token: string, tenantId?: string): Promise<JWTPayload> {
    try {
      const payload = jwt.verify(token, this.jwtSecret, {
        issuer: 'kyo-system',
        audience: tenantId,
      }) as JWTPayload;
      // 檢查 Token 是否被撤銷(可選的黑名單機制)
      await this.checkTokenBlacklist(payload);
      return payload;
    } catch (error) {
      if (error instanceof jwt.TokenExpiredError) {
        throw new KyoError('E_AUTH_TOKEN_EXPIRED', 'Token has expired', 401);
      } else if (error instanceof jwt.JsonWebTokenError) {
        throw new KyoError('E_AUTH_TOKEN_INVALID', 'Invalid token', 401);
      }
      throw error;
    }
  }
}
會員管理是健身房 SaaS 的核心功能,我們實作了完整的 CRUD 操作和進階查詢:
// packages/kyo-core/src/services/member-service.ts
export class MemberService {
  constructor(private connectionManager: TenantConnectionManager) {}
  async searchMembers(tenantId: string, params: MemberSearchParams): Promise<MemberSearchResult> {
    const pool = await this.connectionManager.getConnection(tenantId);
    // 建構查詢條件
    const conditions: string[] = [];
    const values: any[] = [];
    let paramIndex = 1;
    if (params.keyword) {
      conditions.push(`(
        m.name ILIKE $${paramIndex} OR
        m.member_code ILIKE $${paramIndex} OR
        m.email ILIKE $${paramIndex} OR
        m.phone ILIKE $${paramIndex}
      )`);
      values.push(`%${params.keyword}%`);
      paramIndex++;
    }
    if (params.status) {
      conditions.push(`m.status = $${paramIndex}`);
      values.push(params.status);
      paramIndex++;
    }
    if (params.joinDateFrom) {
      conditions.push(`m.join_date >= $${paramIndex}`);
      values.push(params.joinDateFrom);
      paramIndex++;
    }
    if (params.joinDateTo) {
      conditions.push(`m.join_date <= $${paramIndex}`);
      values.push(params.joinDateTo);
      paramIndex++;
    }
    // 建構完整查詢
    const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
    const orderBy = `ORDER BY ${params.sortBy || 'm.created_at'} ${params.sortOrder || 'DESC'}`;
    const limit = params.limit || 20;
    const offset = ((params.page || 1) - 1) * limit;
    // 執行查詢
    const query = `
      SELECT
        m.*,
        ms.plan,
        ms.status as membership_status,
        ms.start_date as membership_start,
        ms.end_date as membership_end,
        (
          SELECT COUNT(*)
          FROM check_ins ci
          WHERE ci.member_id = m.id
          AND ci.check_in_time >= NOW() - INTERVAL '30 days'
        ) as visits_last_30_days,
        (
          SELECT ci.check_in_time
          FROM check_ins ci
          WHERE ci.member_id = m.id
          ORDER BY ci.check_in_time DESC
          LIMIT 1
        ) as last_visit
      FROM members m
      LEFT JOIN memberships ms ON m.id = ms.member_id AND ms.is_active = true
      ${whereClause}
      ${orderBy}
      LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
    `;
    values.push(limit, offset);
    const result = await pool.query(query, values);
    // 取得總數
    const countQuery = `
      SELECT COUNT(*) as total
      FROM members m
      LEFT JOIN memberships ms ON m.id = ms.member_id AND ms.is_active = true
      ${whereClause}
    `;
    const countResult = await pool.query(countQuery, values.slice(0, -2));
    const totalCount = parseInt(countResult.rows[0].total);
    return {
      members: result.rows.map(row => this.mapRowToMember(row)),
      pagination: {
        currentPage: params.page || 1,
        totalPages: Math.ceil(totalCount / limit),
        totalCount,
        pageSize: limit,
      },
    };
  }
  async checkInMember(tenantId: string, identifier: string): Promise<CheckInResult> {
    const pool = await this.connectionManager.getConnection(tenantId);
    try {
      await pool.query('BEGIN');
      // 尋找會員
      const member = await this.findMemberByIdentifier(pool, identifier);
      if (!member) {
        throw new KyoError('E_MEMBER_NOT_FOUND', 'Member not found', 404);
      }
      // 檢查會員狀態
      if (member.membershipInfo.status !== 'active') {
        throw new KyoError('E_MEMBERSHIP_INACTIVE', 'Membership is not active', 400);
      }
      // 檢查是否已經報到
      const todayCheckin = await pool.query(`
        SELECT id FROM check_ins
        WHERE member_id = $1
        AND DATE(check_in_time) = CURRENT_DATE
        AND check_out_time IS NULL
      `, [member.id]);
      if (todayCheckin.rows.length > 0) {
        throw new KyoError('E_ALREADY_CHECKED_IN', 'Member already checked in today', 400);
      }
      // 記錄報到
      const checkInResult = await pool.query(`
        INSERT INTO check_ins (id, member_id, check_in_time, created_at)
        VALUES ($1, $2, $3, $4)
        RETURNING *
      `, [
        `checkin-${Date.now()}-${Math.random()}`,
        member.id,
        new Date(),
        new Date()
      ]);
      // 更新會員最後到訪時間
      await pool.query(`
        UPDATE members
        SET last_visit = $1, visit_count = visit_count + 1
        WHERE id = $2
      `, [new Date(), member.id]);
      await pool.query('COMMIT');
      return {
        success: true,
        member: member,
        checkIn: this.mapRowToCheckIn(checkInResult.rows[0]),
        message: '報到成功',
      };
    } catch (error) {
      await pool.query('ROLLBACK');
      throw error;
    }
  }
}
我們使用 Fastify 建立 RESTful API,支援租戶上下文和認證授權:
// apps/kyo-otp-service/src/routes/tenant-routes.ts
export async function tenantRoutes(fastify: FastifyInstance) {
  // 租戶身份驗證中間件
  fastify.addHook('preHandler', async (request) => {
    const tenantId = request.headers['x-tenant-id'] as string;
    if (!tenantId) {
      throw new Error('Tenant ID is required');
    }
    request.tenantId = tenantId;
  });
  // 會員搜尋
  fastify.get('/members', {
    schema: {
      querystring: MemberSearchParamsSchema,
      response: { 200: MemberSearchResultSchema },
    },
  }, async (request) => {
    const params = request.query as MemberSearchParams;
    return await memberService.searchMembers(request.tenantId, params);
  });
  // 會員報到
  fastify.post('/members/check-in', {
    schema: {
      body: Type.Object({
        identifier: Type.String({ minLength: 1, description: '會員編號、手機或email' }),
      }),
      response: { 200: CheckInResultSchema },
    },
  }, async (request) => {
    const { identifier } = request.body as { identifier: string };
    return await memberService.checkInMember(request.tenantId, identifier);
  });
  // 更新會員狀態
  fastify.patch('/members/:id/status', {
    schema: {
      params: Type.Object({ id: Type.String() }),
      body: Type.Object({ status: Type.Enum(MemberStatus) }),
    },
  }, async (request) => {
    const { id } = request.params as { id: string };
    const { status } = request.body as { status: MemberStatus };
    return await memberService.updateMemberStatus(request.tenantId, id, status);
  });
}
// 連線監控
async getConnectionStats(): Promise<ConnectionStats[]> {
  const stats: ConnectionStats[] = [];
  for (const [tenantId, connection] of this.connections) {
    stats.push({
      tenantId,
      activeConnections: connection.pool.totalCount,
      idleConnections: connection.pool.idleCount,
      waitingClients: connection.pool.waitingCount,
      lastUsed: connection.lastUsed,
      isHealthy: connection.isActive,
    });
  }
  return stats;
}
// 效能監控
private async logSlowQuery(query: string, duration: number, tenantId: string) {
  if (duration > 1000) { // 超過1秒的查詢
    console.warn('Slow query detected:', {
      tenantId,
      query: query.substring(0, 100),
      duration,
      timestamp: new Date(),
    });
  }
}
我們今天實作了完整的後端多租戶架構: